// user.js
//
// A local user; distinct from a person
//
// Copyright 2011,2012 E14N https://e14n.com/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

"use strict";

var databank = require("databank"),
    _ = require("lodash"),
    DatabankObject = databank.DatabankObject,
    Stamper = require("../stamper").Stamper,
    bcrypt = require("bcryptjs"),
    Step = require("step"),
    Person = require("./person").Person,
    Stream = require("./stream").Stream,
    Collection = require("./collection").Collection,
    Activity = require("./activity").Activity,
    ActivityObject = require("./activityobject").ActivityObject,
    Favorite = require("./favorite").Favorite,
    URLMaker = require("../urlmaker").URLMaker,
    IDMaker = require("../idmaker").IDMaker,
    Edge = require("./edge").Edge,
    NoSuchThingError = databank.NoSuchThingError,
    NICKNAME_RE = /^[a-zA-Z0-9\-_.]{1,64}$/,
    nicknameBlacklist = require("../nicknameblacklist");

var User = DatabankObject.subClass("user");

exports.User = User;

// for updating

User.prototype.beforeUpdate = function(props, callback) {

    // XXX: required, but immutable. Boooooo.

    if (!_(props).has("nickname")) {
        callback(new Error("'nickname' property is required"), null);
        return;
    } else if (props.nickname !== this.nickname) {
        callback(new Error("'nickname' is immutable"), null);
        return;
    } else {
        delete props.nickname;
    }

    // XXX: required. Seems not strictly necessary, but whatever.

    if (!_(props).has("password")) {
        callback(new Error("'password' property is required"), null);
        return;
    }

    if (User.isBadPassword(props.password)) {
        callback(new User.BadPasswordError(), null);
        return;
    }

    // Callers must omit or leave equal

    if (_(props).has("published")) {
        if (props.published !== this.published) {
            callback(new Error("'published' is autogenerated and immutable"), null);
            return;
        } else {
            delete props.published;
        }
    }

    // Callers must omit or leave equal

    if (_(props).has("profile")) {
        // XXX: we should probably do some deep-equality check
        if (!_(props.profile).has("id") || props.profile.id !== this.profile.id ||
            !_(props.profile).has("objectType") || props.profile.objectType !== this.profile.objectType ||
            !_(props.profile).has("displayName") || props.profile.displayName !== this.profile.displayName) {
            callback(new Error("'profile' is immutable"), null);
            return;
        } else {
            delete props.profile;
        }
    }

    // Callers must omit or leave equal

    if (_(props).has("updated") && props.updated !== this.updated) {
        callback(new Error("'updated' is autogenerated"), null);
        return;
    }

    props.updated = Stamper.stamp();

    Step(
        function() {
            bcrypt.genSalt(10, this);
        },
        function(err, salt) {
            if (err) throw err;
            bcrypt.hash(props.password, salt, this);
        },
        function(err, hash) {
            if (err) {
                callback(err, null);
            } else {
                props._passwordHash = hash;
                delete props.password;
                callback(null, props);
            }
        }
    );
};

User.BadNicknameError = function(nickname) {
    Error.captureStackTrace(this, User.BadNicknameError);
    this.name = "BadNicknameError";
    this.message = "Bad nickname: '" + nickname + "'";
    this.nickname = nickname;
};

User.BadNicknameError.prototype = new Error();
User.BadNicknameError.prototype.constructor = User.BadNicknameError;

User.BadPasswordError = function() {
    Error.captureStackTrace(this, User.BadPasswordError);
    this.name = "BadPasswordError";
    this.message = "Bad password";
};

User.BadPasswordError.prototype = new Error();
User.BadPasswordError.prototype.constructor = User.BadPasswordError;

// For creating

User.beforeCreate = function(props, callback) {

    if (User.isBadNickname(props.nickname)) {
        callback(new User.BadNicknameError(props.nickname), null);
        return;
    }

    if (User.isBadPassword(props.password)) {
        callback(new User.BadPasswordError(), null);
        return;
    }

    var now = Stamper.stamp();

    props.published = props.updated = now;

    Step(
        function() {
            bcrypt.genSalt(10, this);
        },
        function(err, salt) {
            if (err) throw err;
            bcrypt.hash(props.password, salt, this);
        },
        function(err, hash) {
            var id;
            if (err) throw err;
            props._passwordHash = hash;
            delete props.password;

            if (err) {
                callback(err, null);
            } else {

                if (URLMaker.port === 80 || URLMaker.port === 443) {
                    id = "acct:" + props.nickname + "@" + URLMaker.hostname;
                } else {
                    id = URLMaker.makeURL("api/user/" + props.nickname + "/profile");
                }

                props.profile = new Person({
                    objectType: "person",
                    id: id
                });

                callback(null, props);
            }
        }
    );
};

User.prototype.afterCreate = function(callback) {

    var user = this,
        createPerson = function(callback) {

            var pprops = {"preferredUsername": user.nickname,
                          _user: true,
                          url: URLMaker.makeURL(user.nickname),
                          displayName: user.nickname};

            pprops._uuid = IDMaker.makeID();

            // If we're on the http or https default port, use acct: IDs

            if (URLMaker.port === 80 || URLMaker.port === 443) {
                pprops.id = "acct:" + user.nickname + "@" + URLMaker.hostname;
            } else {
                pprops.id = URLMaker.makeURL("api/user/" + user.nickname + "/profile");
            }

            pprops.links = {
                self: {
                    href: URLMaker.makeURL("api/person/" + pprops._uuid)
                },
                "activity-inbox": {
                    href: URLMaker.makeURL("api/user/" + user.nickname + "/inbox")
                },
                "activity-outbox": {
                    href: URLMaker.makeURL("api/user/" + user.nickname + "/feed")
                }
            };

            Person.create(pprops, callback);
        },
        createStreams = function(callback) {
            Step(
                function() {
                    var i,
                        streams = ["inbox",
                                   "outbox",
                                   "inbox:major",
                                   "outbox:major",
                                   "inbox:minor",
                                   "outbox:minor",
                                   "inbox:direct",
                                   "inbox:direct:minor",
                                   "inbox:direct:major",
                                   "followers",
                                   "following",
                                   "favorites",
                                   "uploads",
                                   "lists:person"
                                  ],
                        group = this.group();

                    for (i = 0; i < streams.length; i++) {
                        Stream.create({name: "user:" + user.nickname + ":" + streams[i]}, group());
                    }
                },
                callback
            );
        },
        createGalleries = function(callback) {
            Step(
                function() {
                    Stream.create({name: "user:" + user.nickname + ":lists:image"}, this);
                },
                function(err, str) {
                    var i,
                        lists = ["Profile Pictures"],
                        group = this.group();

                    if (err) throw err;

                    for (i = 0; i < lists.length; i++) {
                        Collection.create({author: user.profile, displayName: lists[i], objectTypes: ["image"]}, group());
                    }
                },
                callback
            );
        },
        createVirtualLists = function(callback) {

            Step(
                function() {
                    var i,
                        rels = {"followers": "Followers",
                                "following": "Following"},
                        group = this.group();

                    _.each(rels, function(name, rel) {
                        var id = URLMaker.makeURL("/api/user/"+user.nickname+"/"+rel),
                            url = URLMaker.makeURL("/"+user.nickname+"/"+rel);
                        Collection.create({author: user.profile,
                                           id: id,
                                           links: {
                                               self: {
                                                   href: id
                                               }
                                           },
                                           url: url,
                                           displayName: name,
                                           members: {
                                               url: id
                                           }
                                          },
                                          group());
                    });
                },
                callback
            );
        };

    Step(
        function() {
            createPerson(this.parallel());
            createStreams(this.parallel());
            createGalleries(this.parallel());
            createVirtualLists(this.parallel());
        },
        function(err, person, streams, galleries, virtuals) {
            if (err) {
                callback(err);
            } else {
                user.profile = person;
                callback(null);
            }
        }
    );
};

// Remove any attributes we don't want to appear in API output.
// By convention, we wipe everything starting with "_".

User.prototype.sanitize = function() {

    var user = this;

    _.each(user, function(value, key) {
        if (key[0] === "_") {
            delete user[key];
        }
    });

    delete this.password;

    if (this.profile && this.profile.sanitize) {
        this.profile.sanitize();
    }
};

User.prototype.getProfile = function(callback) {
    var user = this;
    Step(
        function() {
            ActivityObject.getObject(user.profile.objectType, user.profile.id, this);
        },
        function(err, profile) {
            if (err) {
                callback(err, null);
            } else {
                callback(null, profile);
            }
        }
    );
};

User.prototype.followersStream = function(callback) {
    var user = this;
    Stream.get("user:" + user.nickname + ":followers", callback);
};

User.prototype.followingStream = function(callback) {
    var user = this;
    Stream.get("user:" + user.nickname + ":following", callback);
};

User.prototype.getFollowers = function(start, end, callback) {
    this.getPeople("user:" + this.nickname + ":followers", start, end, callback);
};

User.prototype.getFollowing = function(start, end, callback) {
    this.getPeople("user:" + this.nickname + ":following", start, end, callback);
};

User.prototype.getPeople = function(stream, start, end, callback) {
    ActivityObject.getObjectStream("person", stream, start, end, callback);
};

User.prototype.followerCount = function(callback) {
    Stream.count("user:" + this.nickname + ":followers", callback);
};

User.prototype.followingCount = function(callback) {
    Stream.count("user:" + this.nickname + ":following", callback);
};

User.prototype.follow = function(other, callback) {
    var user = this;

    Step(
        function() {
            Edge.create({from: user.profile, to: other.profile},
                        this);
        },
        function(err, edge) {
            var group = this.group();
            if (err) throw err;
            user.addFollowing(other.profile.id, group());
            other.addFollower(user.profile.id, group());
        },
        function(err) {
            if (err) {
                callback(err);
            } else {
                callback(null);
            }
        }
    );
};

User.prototype.stopFollowing = function(other, callback) {
    var user = this;

    Step(
        function() {
            Edge.get(Edge.id(user.profile.id, other.profile.id), this);
        },
        function(err, edge) {
            if (err) throw err;
            edge.del(this);
        },
        function(err) {
            var group = this.group();
            if (err) throw err;
            user.removeFollowing(other.profile.id, group());
            other.removeFollower(user.profile.id, group());
        },
        function(err) {
            if (err) {
                callback(err);
            } else {
                callback(null);
            }
        }
    );
};

User.prototype.addFollowing = function(id, callback) {
    var user = this;
    Step(
        function() {
            Stream.get("user:" + user.nickname + ":following", this);
        },
        function(err, stream) {
            if (err) throw err;
            stream.deliver(id, this);
        },
        callback
    );
};

User.prototype.addFollower = function(id, callback) {
    var user = this;
    Step(
        function() {
            Stream.get("user:" + user.nickname + ":followers", this);
        },
        function(err, stream) {
            if (err) throw err;
            stream.deliver(id, this);
        },
        callback
    );
};

User.prototype.removeFollowing = function(id, callback) {
    var user = this;
    Step(
        function() {
            Stream.get("user:" + user.nickname + ":following", this);
        },
        function(err, stream) {
            if (err) throw err;
            stream.remove(id, this);
        },
        callback
    );
};

User.prototype.removeFollower = function(id, callback) {
    var user = this;
    Step(
        function() {
            Stream.get("user:" + user.nickname + ":followers", this);
        },
        function(err, stream) {
            if (err) throw err;
            stream.remove(id, this);
        },
        callback
    );
};

User.prototype.addToFavorites = function(object, callback) {
    var user = this;
    Step(
        function() {
            user.favoritesStream(this);
        },
        function(err, stream) {
            if (err) throw err;
            stream.deliverObject({id: object.id, objectType: object.objectType}, this);
        },
        callback
    );
};

User.prototype.removeFromFavorites = function(object, callback) {
    var user = this;
    Step(
        function() {
            user.favoritesStream(this);
        },
        function(err, stream) {
            if (err) throw err;
            stream.removeObject({id: object.id, objectType: object.objectType}, this);
        },
        callback
    );
};

User.prototype.getFavorites = function(start, end, callback) {
    var user = this;

    Step(
        function() {
            user.favoritesStream(this);
        },
        function(err, stream) {
            if (err) throw err;
            stream.getObjects(start, end, this);
        },
        function(err, refs) {
            var i, parts, group = this.group();
            if (err) throw err;
            if (refs.length === 0) {
                callback(null, []);
            } else {
                for (i = 0; i < refs.length; i++) {
                    ActivityObject.getObject(refs[i].objectType, refs[i].id, group());
                }
            }
        },
        function(err, objects) {
            if (err) {
                callback(err, null);
            } else {
                // XXX: I *think* these should be in the same order
                // as the refs array.
                callback(null, objects);
            }
        }
    );
};

User.prototype.favoritesCount = function(callback) {
    var user = this;
    Step(
        function() {
            user.favoritesStream(this);
        },
        function(err, stream) {
            if (err) throw err;
            stream.count(this);
        },
        callback
    );
};

User.prototype.favoritesStream = function(callback) {
    Stream.get("user:" + this.nickname + ":favorites", function(err, stream) {
        if (err && err.name === "NoSuchThingError") {
            Stream.create("user:" + this.nickname + ":favorites", callback);
        } else if (err) {
            callback(err, null);
        } else {
            callback(null, stream);
        }
    });
};

User.prototype.uploadsStream = function(callback) {
    var user = this;
    Stream.get("user:" + user.nickname + ":uploads", callback);
};

User.prototype.expand = function(callback) {
    var user = this;

    ActivityObject.expandProperty(user, "profile", callback);
};

User.fromPerson = function(id, callback) {
    Step(
        function() {
            User.search({"profile.id": id}, this);
        },
        function(err, results) {
            if (err) {
                callback(err, null);
            } else if (results.length === 0) {
                callback(null, null);
            } else {
                callback(null, results[0]);
            }
        }
    );
};

User.prototype.addToOutbox = function(activity, callback) {
    var user = this,
        adder = function(getter) {
            return function(user, activity, callback) {
                Step(
                    function() {
                        getter(user, this);
                    },
                    function(err, stream) {
                        if (err) throw err;
                        stream.deliver(activity.id, callback);
                    }
                );
            };
        },
        addToMain = adder(function(user, cb) { user.getOutboxStream(cb); }),
        addToMajor = adder(function(user, cb) { user.getMajorOutboxStream(cb); }),
        addToMinor = adder(function(user, cb) { user.getMinorOutboxStream(cb); });

    Step(
        function() {
            addToMain(user, activity, this.parallel());
            if (activity.isMajor()) {
                addToMajor(user, activity, this.parallel());
            } else {
                addToMinor(user, activity, this.parallel());
            }
        },
        callback
    );
};

User.prototype.addToInbox = function(activity, callback) {
    var user = this,
        adder = function(getter) {
            return function(user, activity, callback) {
                Step(
                    function() {
                        getter(user, this);
                    },
                    function(err, stream) {
                        if (err) throw err;
                        stream.deliver(activity.id, this);
                    },
                    callback
                );
            };
        },
        isDirectTo = function(activity, user) {
            var props = ["to", "bto"],
                addrs,
                i,
                j;

            for (i = 0; i < props.length; i++) {
                if (_.has(activity, props[i])) {
                    addrs = activity[props[i]];
                    for (j = 0; j < addrs.length; j++) {
                        if (_.has(addrs[j], "id") &&
                            addrs[j].id === user.profile.id) {
                            return true;
                        }
                    }
                }
            }

            return false;
        },
        addToMain = adder(function(user, cb) { user.getInboxStream(cb); }),
        addToMajor = adder(function(user, cb) { user.getMajorInboxStream(cb); }),
        addToDirect = adder(function(user, cb) { user.getDirectInboxStream(cb); }),
        addToMinorDirect = adder(function(user, cb) { user.getMinorDirectInboxStream(cb); }),
        addToMajorDirect = adder(function(user, cb) { user.getMajorDirectInboxStream(cb); }),
        addToMinor = adder(function(user, cb) { user.getMinorInboxStream(cb); });

    Step(
        function() {
            var direct = isDirectTo(activity, user);
            addToMain(user, activity, this.parallel());
            if (direct) {
                addToDirect(user, activity, this.parallel());
            }
            if (activity.isMajor()) {
                addToMajor(user, activity, this.parallel());
                if (direct) {
                    addToMajorDirect(user, activity, this.parallel());
                }
            } else {
                addToMinor(user, activity, this.parallel());
                if (direct) {
                    addToMinorDirect(user, activity, this.parallel());
                }
            }
        },
        callback
    );
};

// Check the credentials for a user
// callback takes args:
// - err: if there's an error (NB: null if credentials don't match)
// - user: User object or null if credentials don't match

User.checkCredentials = function(nickname, password, callback) {
    var user = null;

    Step(
        function() {
            User.get(nickname, this);
        },
        function(err, result) {
            if (err) {
                if (err.name === "NoSuchThingError") {
                    callback(null, null);
                    return; // done
                } else {
                    throw err;
                }
            } else {
                user = result;
                bcrypt.compare(password, user._passwordHash, this);
            }
        },
        function(err, res) {
            if (err) {
                callback(err, null);
            } else if (!res) {
                callback(null, null);
            } else {
                // Don't percolate that hash around
                user.sanitize();
                callback(null, user);
            }
        }
    );
};

User.prototype.getInboxStream = function(callback) {
    Stream.get("user:" + this.nickname + ":inbox", callback);
};

User.prototype.getOutboxStream = function(callback) {
    Stream.get("user:" + this.nickname + ":outbox", callback);
};

User.prototype.getMajorInboxStream = function(callback) {
    Stream.get("user:" + this.nickname + ":inbox:major", callback);
};

User.prototype.getMajorOutboxStream = function(callback) {
    Stream.get("user:" + this.nickname + ":outbox:major", callback);
};

User.prototype.getMinorInboxStream = function(callback) {
    Stream.get("user:" + this.nickname + ":inbox:minor", callback);
};

User.prototype.getMinorOutboxStream = function(callback) {
    Stream.get("user:" + this.nickname + ":outbox:minor", callback);
};

User.prototype.getLists = function(type, callback) {
    var streamName = "user:" + this.nickname + ":lists:" + type;
    Step(
        function() {
            Stream.get(streamName, this);
        },
        function(err, str) {
            if (err && err.name === "NoSuchThingError") {
                Stream.create({name: streamName}, this);
            } else if (err) {
                throw err;
            } else {
                this(null, str);
            }
        },
        callback
    );
};

User.prototype.getDirectInboxStream = function(callback) {
    Stream.get("user:" + this.nickname + ":inbox:direct", callback);
};

User.prototype.getMinorDirectInboxStream = function(callback) {
    Stream.get("user:" + this.nickname + ":inbox:direct:minor", callback);
};

User.prototype.getMajorDirectInboxStream = function(callback) {
    Stream.get("user:" + this.nickname + ":inbox:direct:major", callback);
};

// I keep forgetting these

User.prototype.getDirectMinorInboxStream = User.prototype.getMinorDirectInboxStream;
User.prototype.getDirectMajorInboxStream = User.prototype.getMajorDirectInboxStream;

User.isBadPassword = function(password) {

    var badPassword;

    // Can't be empty or null

    if (!password) {
        return true;
    }

    // Can't be less than 8

    if (password.length < 8) {
        return true;
    }

    // Can't be all-alpha or all-numeric

    if (/^[a-z]+$/.test(password.toLowerCase()) ||
        /^[0-9]+$/.test(password))
    {
        return true;
    }

    badPassword = require("../badpassword");

    // Can't be on list of top 10K passwords

    if (_.has(badPassword, password)) {
        return true;
    }

    return false;
};

User.isBadNickname = function(nickname) {

    if (!nickname) {
        return true;
    }

    if (!NICKNAME_RE.test(nickname)) {
        return true;
    }

    // Since we use /<nickname> as an URL, we can't have top-level URL
    // as user nicknames.

    if (nicknameBlacklist.indexOf(nickname) !== -1) {
        return true;
    }

    return false;
};

User.schema = {"pkey": "nickname",
               "fields": ["_passwordHash",
                          "email",
                          "published",
                          "updated",
                          "profile"],
               "indices": ["profile.id", "email"]};
